summaryrefslogtreecommitdiff
path: root/app/api/auth/[...nextauth]/saml
diff options
context:
space:
mode:
Diffstat (limited to 'app/api/auth/[...nextauth]/saml')
-rw-r--r--app/api/auth/[...nextauth]/saml/provider.ts259
-rw-r--r--app/api/auth/[...nextauth]/saml/utils.ts485
2 files changed, 744 insertions, 0 deletions
diff --git a/app/api/auth/[...nextauth]/saml/provider.ts b/app/api/auth/[...nextauth]/saml/provider.ts
new file mode 100644
index 00000000..8486a690
--- /dev/null
+++ b/app/api/auth/[...nextauth]/saml/provider.ts
@@ -0,0 +1,259 @@
+import CredentialsProvider from "next-auth/providers/credentials"
+import { getOrCreateSAMLUser, validateSAMLUserData } from '@/lib/users/saml-service'
+import { encode } from 'next-auth/jwt'
+import type { User } from 'next-auth'
+import type { SAMLUser } from './utils'
+import { debugLog, debugError, debugSuccess, debugProcess } from '@/lib/debug-utils'
+
+interface SAMLProviderOptions {
+ id: string
+ name: string
+ idp: {
+ sso_login_url: string
+ sso_logout_url: string
+ certificates: string[]
+ }
+ sp: {
+ entity_id: string
+ private_key: string
+ certificate: string
+ assert_endpoint: string
+ }
+}
+
+export function SAMLProvider(options: SAMLProviderOptions) {
+ return CredentialsProvider({
+ id: options.id,
+ name: options.name,
+ credentials: {
+ user: {
+ label: "User Data",
+ type: "text"
+ }
+ },
+ async authorize(credentials) {
+ debugLog('πŸ” SAMLProvider.authorize called with credentials:', credentials);
+
+ try {
+ debugLog('πŸ” Checking credentials.user:', {
+ hasCredentials: !!credentials,
+ hasUser: !!credentials?.user,
+ userType: typeof credentials?.user,
+ userValue: credentials?.user?.substring?.(0, 100) + '...'
+ });
+
+ if (!credentials?.user) {
+ debugError('No user data provided in credentials')
+ return null
+ }
+
+ debugProcess('SAML Provider: Processing user data')
+
+ // μ‚¬μš©μž 데이터 νŒŒμ‹± (UTF-8 처리 κ°œμ„ )
+ const userDataString = credentials.user
+ debugLog('πŸ”€ Raw user data string:', userDataString.substring(0, 200) + '...')
+
+ let userData;
+ try {
+ userData = JSON.parse(userDataString);
+ debugSuccess('JSON parsing successful:', userData);
+ } catch (parseError) {
+ debugError('JSON parsing failed:', parseError);
+ debugError('Raw string that failed to parse:', userDataString);
+ return null;
+ }
+
+ // νŒŒμ‹±λœ λ°μ΄ν„°μ˜ UTF-8 확인
+ debugLog('πŸ”€ Parsed user data UTF-8 check:', {
+ name: userData.name,
+ nameLength: userData.name?.length,
+ charCodes: userData.name ? [...userData.name].map(c => c.charCodeAt(0)) : []
+ })
+
+ if (!userData.id || !userData.email) {
+ debugError('Invalid SAML user data:', userData)
+ return null
+ }
+
+ debugSuccess('SAML Provider: User authenticated successfully', {
+ id: userData.id,
+ email: userData.email,
+ name: userData.name
+ })
+
+ // πŸ”₯ SAML μ‚¬μš©μž 데이터 검증
+ debugProcess('Validating SAML user data structure...');
+ const isValidData = await validateSAMLUserData(userData)
+ debugLog('Validation result:', isValidData);
+ if (!isValidData) {
+ debugError('Invalid SAML user data structure:', userData)
+ return null
+ }
+
+ // πŸ”₯ JIT (Just-In-Time) μ‚¬μš©μž 생성 λ˜λŠ” 쑰회
+ debugProcess('Creating/getting SAML user from database...');
+ const userCreateData = {
+ email: userData.email,
+ name: userData.name,
+ companyId: undefined,
+ techCompanyId: undefined,
+ domain: userData.domain
+ };
+ debugLog('User create data:', userCreateData);
+
+ const dbUser = await getOrCreateSAMLUser(userCreateData)
+ debugLog('Database user result:', dbUser);
+
+ if (!dbUser) {
+ debugError('Failed to get or create SAML user')
+ return null
+ }
+
+ // DBμ—μ„œ κ°€μ Έμ˜¨ μ‹€μ œ μ‚¬μš©μž 정보 λ°˜ν™˜
+ const userResult = {
+ id: String(dbUser.id), // DB의 μ‹€μ œ ID
+ name: dbUser.name, // DB의 μ‹€μ œ 이름
+ email: dbUser.email, // DB의 μ‹€μ œ 이메일
+ companyId: dbUser.companyId, // DB의 μ‹€μ œ νšŒμ‚¬ ID
+ techCompanyId: dbUser.techCompanyId, // DB의 μ‹€μ œ κΈ°μˆ νšŒμ‚¬ ID
+ domain: dbUser.domain, // DB의 μ‹€μ œ 도메인
+ imageUrl: dbUser.imageUrl, // DB의 μ‹€μ œ 이미지 URL
+ }
+
+ debugSuccess('SAML Provider: Returning user data to NextAuth:', userResult)
+ return userResult
+ } catch (error) {
+ debugError('SAML Provider: Authentication failed', {
+ error: error instanceof Error ? error.message : String(error),
+ stack: error instanceof Error ? error.stack : undefined,
+ errorType: typeof error,
+ credentials: credentials
+ });
+ return null
+ }
+ }
+ })
+}
+
+// SAML 둜그인 URL 생성 헬퍼 ν•¨μˆ˜
+export function getSAMLLoginUrl(options: SAMLProviderOptions): string {
+ const params = new URLSearchParams({
+ SAMLRequest: 'placeholder', // μ‹€μ œλ‘œλŠ” createAuthnRequest()둜 생성
+ RelayState: options.sp.assert_endpoint,
+ })
+
+ return `${options.idp.sso_login_url}?${params.toString()}`
+}
+
+// SAML μ„€μ • 검증
+export function validateSAMLOptions(options: SAMLProviderOptions): boolean {
+ const required = [
+ options.idp.sso_login_url,
+ options.sp.entity_id,
+ options.sp.assert_endpoint
+ ]
+
+ return required.every(field => field && field.length > 0)
+}
+
+// SAMLProvider의 authorize ν•¨μˆ˜λ₯Ό 직접 ν˜ΈμΆœν•˜κΈ° μœ„ν•œ 헬퍼
+export async function authenticateSAMLUser(userData: SAMLUser) {
+ debugLog('authenticateSAMLUser called with:', userData);
+
+ try {
+ // SAMLProvider λŒ€μ‹  직접 둜직 μ‹€ν–‰ (Provider 래퍼 없이)
+ debugProcess('SAML User Authentication: Processing user data')
+
+ // μ‚¬μš©μž 데이터 검증
+ if (!userData.id || !userData.email) {
+ debugError('Invalid SAML user data:', userData)
+ return null
+ }
+
+ debugSuccess('SAML User data validated successfully', {
+ id: userData.id,
+ email: userData.email,
+ name: userData.name
+ })
+
+ // πŸ”₯ SAML μ‚¬μš©μž 데이터 검증
+ debugLog('Validating SAML user data structure...');
+ const isValidData = await validateSAMLUserData(userData)
+ debugLog('Validation result:', isValidData);
+ if (!isValidData) {
+ debugError('Invalid SAML user data structure:', userData)
+ return null
+ }
+
+ // πŸ”₯ JIT (Just-In-Time) μ‚¬μš©μž 생성 λ˜λŠ” 쑰회
+ debugLog('Creating/getting SAML user from database...');
+ const userCreateData = {
+ email: userData.email,
+ name: userData.name,
+ companyId: undefined,
+ techCompanyId: undefined,
+ domain: userData.domain
+ };
+ debugLog('User create data:', userCreateData);
+
+ const dbUser = await getOrCreateSAMLUser(userCreateData)
+ debugLog('Database user result:', dbUser);
+
+ if (!dbUser) {
+ debugError('Failed to get or create SAML user')
+ return null
+ }
+
+ // DBμ—μ„œ κ°€μ Έμ˜¨ μ‹€μ œ μ‚¬μš©μž 정보 λ°˜ν™˜
+ const userResult = {
+ id: String(dbUser.id), // DB의 μ‹€μ œ ID
+ name: dbUser.name, // DB의 μ‹€μ œ 이름
+ email: dbUser.email, // DB의 μ‹€μ œ 이메일
+ companyId: dbUser.companyId, // DB의 μ‹€μ œ νšŒμ‚¬ ID
+ techCompanyId: dbUser.techCompanyId, // DB의 μ‹€μ œ κΈ°μˆ νšŒμ‚¬ ID
+ domain: dbUser.domain, // DB의 μ‹€μ œ 도메인
+ imageUrl: dbUser.imageUrl, // DB의 μ‹€μ œ 이미지 URL
+ }
+
+ debugSuccess('SAML User Authentication completed:', userResult)
+ return userResult;
+
+ } catch (error) {
+ debugError('authenticateSAMLUser error:', {
+ error: error instanceof Error ? error.message : String(error),
+ stack: error instanceof Error ? error.stack : undefined,
+ userData
+ });
+ return null;
+ }
+}
+
+// NextAuth JWT 토큰 생성 헬퍼
+export async function createNextAuthToken(user: User): Promise<string> {
+ const token = {
+ id: user.id,
+ email: user.email,
+ name: user.name,
+ companyId: user.companyId,
+ techCompanyId: user.techCompanyId,
+ domain: user.domain,
+ imageUrl: user.imageUrl,
+ iat: Math.floor(Date.now() / 1000),
+ exp: Math.floor(Date.now() / 1000) + (30 * 24 * 60 * 60) // 30일
+ };
+
+ const secret = process.env.NEXTAUTH_SECRET!;
+ return await encode({ token, secret });
+}
+
+// NextAuth μ„Έμ…˜ μΏ ν‚€ 이름 κ°€μ Έμ˜€κΈ°
+export function getSessionCookieName(): string {
+ // NEXTAUTH_URL이 HTTPS인 κ²½μš°μ—λ§Œ __Secure- 접두사 μ‚¬μš©
+ const nextAuthUrl = process.env.NEXTAUTH_URL || '';
+ const isHttps = nextAuthUrl.startsWith('https://');
+
+ return isHttps
+ ? '__Secure-next-auth.session-token'
+ : 'next-auth.session-token';
+}
+ \ No newline at end of file
diff --git a/app/api/auth/[...nextauth]/saml/utils.ts b/app/api/auth/[...nextauth]/saml/utils.ts
new file mode 100644
index 00000000..a5bcfe7a
--- /dev/null
+++ b/app/api/auth/[...nextauth]/saml/utils.ts
@@ -0,0 +1,485 @@
+import { SAML, ValidateInResponseTo } from "@node-saml/node-saml";
+import {
+ getIDPMetadata,
+ normalizeCertificate,
+} from "@/lib/saml/idp-metadata";
+import {
+ getSPMetadata,
+} from "@/lib/saml/sp-metadata";
+import { debugLog, debugError, debugSuccess, debugProcess, debugMock } from '@/lib/debug-utils';
+
+export interface SAMLProfile {
+ nameID?: string;
+ nameIDFormat?: string;
+ attributes?: Record<string, string | string[]>; // λ¬Έμžμ—΄ λ˜λŠ” λ°°μ—΄ λͺ¨λ‘ 지원
+ [key: string]: unknown;
+}
+
+export interface SAMLUser {
+ id: string;
+ email: string;
+ name: string;
+ companyId?: number;
+ techCompanyId?: number;
+ domain?: string;
+}
+
+// SAML μ„€μ • 생성 (sync ν•¨μˆ˜) - ν™˜κ²½λ³€μˆ˜ 기반으둜 λ³€κ²½ν–ˆμŒ
+export function createSAMLConfig() {
+ console.log("βš™οΈ Creating SAML configuration...");
+
+ try {
+ const idpMetadata = getIDPMetadata();
+ const spMetadata = getSPMetadata();
+
+ console.log("πŸ“‹ IdP Metadata loaded:", {
+ entityId: idpMetadata.entityId,
+ ssoUrl: idpMetadata.ssoUrl,
+ organization: idpMetadata.organization,
+ wantAuthnRequestsSigned: idpMetadata.wantAuthnRequestsSigned,
+ });
+
+ console.log("πŸ“‹ SP Metadata loaded:", {
+ entityId: spMetadata.entityId,
+ callbackUrl: spMetadata.callbackUrl,
+ authnRequestsSigned: spMetadata.authnRequestsSigned,
+ });
+
+ const config = {
+ callbackUrl: spMetadata.callbackUrl,
+ // IDP 메타데이터 기반 μ„€μ •
+ entryPoint: idpMetadata.ssoUrl,
+ // SP Entity ID
+ issuer: spMetadata.entityId,
+ // IDP μΈμ¦μ„œ (μ •κ·œν™”λœ PEM ν˜•μ‹)
+ idpCert: normalizeCertificate(idpMetadata.certificate),
+ privateKey: process.env.SAML_SP_PRIVATE_KEY,
+ // IdPμ—μ„œ μš”κ΅¬ν•˜λŠ” μ„€μ •
+ identifierFormat: idpMetadata.nameIdFormat,
+ signatureAlgorithm: "sha256" as const,
+ digestAlgorithm: "sha256",
+ // SP 메타데이터 μ„€μ •
+ decryptionPvk: process.env.SAML_SP_PRIVATE_KEY,
+ publicCert: process.env.SAML_SP_CERT,
+ // IdP 메타데이터 기반 μ„€μ •
+ wantAuthnResponseSigned: idpMetadata.wantAuthnRequestsSigned,
+ wantAssertionsSigned: spMetadata.wantAssertionsSigned,
+ validateInResponseTo: ValidateInResponseTo.never,
+ disableRequestedAuthnContext: true,
+ // HTTP-Redirect 바인딩 μ„€μ •
+ authnRequestBinding: undefined, // HTTP-Redirect (GET) μ‚¬μš© (κΈ°λ³Έκ°’)
+ skipRequestCompression: false, // Deflate μ••μΆ• μ‚¬μš©
+ // μΆ”κ°€ λ³΄μ•ˆ μ„€μ •
+ acceptedClockSkewMs: 5000, // 5초 클럭 차이 ν—ˆμš©
+ forceAuthn: false,
+ // IDP Entity ID μ„€μ •
+ idpIssuer: idpMetadata.entityId,
+ };
+
+ console.log("βœ… SAML Config created:", {
+ callbackUrl: config.callbackUrl,
+ entryPoint: config.entryPoint,
+ issuer: config.issuer,
+ idpIssuer: config.idpIssuer,
+ identifierFormat: config.identifierFormat,
+ hasIdpCert: !!config.idpCert,
+ hasPrivateKey: !!config.privateKey,
+ hasPublicCert: !!config.publicCert,
+ wantAuthnResponseSigned: config.wantAuthnResponseSigned,
+ wantAssertionsSigned: config.wantAssertionsSigned,
+ });
+
+ return config;
+ } catch (error) {
+ console.error("πŸ’₯ Failed to create SAML Config:", error);
+ throw error;
+ }
+}
+
+// SAML AuthnRequest 생성 (μ„œλ²„ μ•‘μ…˜)
+export async function createAuthnRequest(relayState?: string): Promise<string> {
+ "use server";
+
+ console.log("SSO STEP 2: Create AuthnRequest", { relayState });
+
+ // Mock IdP λͺ¨λ“œ 체크
+ if (process.env.SAML_MOCKING_IDP === 'true') {
+ debugMock("Mock IdP mode enabled - simulating SAML response");
+ return createMockSAMLFlow(relayState);
+ }
+
+ try {
+ const config = createSAMLConfig();
+ console.log("SAML Config ready for AuthnRequest generation");
+
+ const saml = new SAML(config);
+ console.log("SAML instance created, generating authorize URL...");
+
+ const startTime = Date.now();
+ const authorizeUrl = await saml.getAuthorizeUrlAsync(
+ relayState || "", // RelayState - μ›λž˜ κ°€λ €λ˜ νŽ˜μ΄μ§€
+ undefined, // host
+ {
+ additionalParams: {},
+ // additionalAuthorizeParams: {},
+ }
+ );
+ const endTime = Date.now();
+
+ // πŸ” SAML AuthnRequest λ””μ½”λ”© 및 뢄석
+ try {
+ const urlObj = new URL(authorizeUrl);
+ const samlRequest = urlObj.searchParams.get("SAMLRequest");
+
+ if (samlRequest) {
+ console.log("SAML AuthnRequest 뢄석:");
+ console.log("1️⃣ 원본 URL:", authorizeUrl);
+ console.log(
+ "2️⃣ URL λ””μ½”λ”©λœ SAMLRequest:",
+ decodeURIComponent(samlRequest)
+ );
+
+ try {
+ // Base64 λ””μ½”λ”©
+ const base64DecodedBuffer = Buffer.from(
+ decodeURIComponent(samlRequest),
+ "base64"
+ );
+ const base64DecodedString = base64DecodedBuffer.toString("utf-8");
+
+ // XML인지 확인 (XML은 '<'둜 μ‹œμž‘ν•¨)
+ if (base64DecodedString.trim().startsWith("<")) {
+ console.log("Base64 λ””μ½”λ”©λœ XML (μ••μΆ• μ—†μŒ):");
+ console.log("───────────────────────────────────");
+ console.log(base64DecodedString);
+ console.log("───────────────────────────────────");
+
+ // XML ꡬ쑰 뢄석
+ const xmlLines = base64DecodedString
+ .split("\n")
+ .filter((line) => line.trim());
+ console.log("XML ꡬ쑰 μš”μ•½:");
+ xmlLines.forEach((line, index) => {
+ const trimmed = line.trim();
+ if (
+ trimmed.includes("<saml") ||
+ trimmed.includes("<samlp") ||
+ trimmed.includes("ID=") ||
+ trimmed.includes("Destination=")
+ ) {
+ console.log(` ${index + 1}: ${trimmed}`);
+ }
+ });
+ } else {
+ // XML이 μ•„λ‹ˆλ©΄ Deflate μ••μΆ•λœ κ²ƒμœΌλ‘œ κ°„μ£Ό
+ console.log(
+ "3️⃣ μ••μΆ•λœ λ°”μ΄λ„ˆλ¦¬ 데이터 감지, Deflate μ••μΆ• ν•΄μ œ μ‹œλ„..."
+ );
+
+ try {
+ const zlib = await import("zlib");
+ const decompressed = zlib
+ .inflateRawSync(base64DecodedBuffer)
+ .toString("utf-8");
+ console.log("Deflate μ••μΆ• ν•΄μ œλœ XML:");
+ console.log("───────────────────────────────────");
+ console.log(decompressed);
+ console.log("───────────────────────────────────");
+
+ // XML ꡬ쑰 뢄석
+ const xmlLines = decompressed
+ .split("\n")
+ .filter((line: string) => line.trim());
+ console.log("XML ꡬ쑰 μš”μ•½:");
+ xmlLines.forEach((line: string, index: number) => {
+ const trimmed = line.trim();
+ if (
+ trimmed.includes("<saml") ||
+ trimmed.includes("<samlp") ||
+ trimmed.includes("ID=") ||
+ trimmed.includes("Destination=") ||
+ trimmed.includes("Issuer>") ||
+ trimmed.includes("AssertionConsumerServiceURL=")
+ ) {
+ console.log(` ${index + 1}: ${trimmed}`);
+ }
+ });
+
+ // μ€‘μš”ν•œ 정보 μΆ”μΆœ
+ const idMatch = decompressed.match(/ID="([^"]+)"/);
+ const destinationMatch = decompressed.match(
+ /Destination="([^"]+)"/
+ );
+ const issuerMatch = decompressed.match(
+ /<saml:Issuer[^>]*>([^<]+)<\/saml:Issuer>/
+ );
+ const acsMatch = decompressed.match(
+ /AssertionConsumerServiceURL="([^"]+)"/
+ );
+
+ console.log("μΆ”μΆœλœ 핡심 정보:");
+ console.log(` Request ID: ${idMatch ? idMatch[1] : "μ—†μŒ"}`);
+ console.log(
+ ` Destination: ${
+ destinationMatch ? destinationMatch[1] : "μ—†μŒ"
+ }`
+ );
+ console.log(
+ ` Issuer: ${issuerMatch ? issuerMatch[1] : "μ—†μŒ"}`
+ );
+ console.log(
+ ` Callback URL: ${acsMatch ? acsMatch[1] : "μ—†μŒ"}`
+ );
+ } catch (inflateError) {
+ console.log("❌ Deflate μ••μΆ• ν•΄μ œ μ‹€νŒ¨:", (inflateError as Error).message);
+ console.log(
+ " 원본 λ°”μ΄λ„ˆλ¦¬ 데이터 (hex):",
+ base64DecodedBuffer.toString("hex").substring(0, 100) + "..."
+ );
+ }
+ }
+ } catch (decodeError) {
+ console.log("❌ Base64 λ””μ½”λ”© μ‹€νŒ¨:", (decodeError as Error).message);
+ }
+ }
+ } catch (analysisError) {
+ console.log("⚠️ SAML AuthnRequest 뢄석 쀑 였λ₯˜:", (analysisError as Error).message);
+ }
+
+ console.log("βœ… SAML AuthnRequest URL generated:", {
+ url: authorizeUrl.substring(0, 100) + "...",
+ fullUrlLength: authorizeUrl.length,
+ processingTime: `${endTime - startTime}ms`,
+ timestamp: new Date().toISOString(),
+ });
+
+ return authorizeUrl;
+ } catch (error) {
+ console.error("πŸ’₯ Failed to create SAML AuthnRequest:", {
+ error: error instanceof Error ? error.message : "Unknown error",
+ stack: error instanceof Error ? error.stack : undefined,
+ timestamp: new Date().toISOString(),
+ });
+ throw error;
+ }
+}
+
+// SAML Response 검증 및 νŒŒμ‹± (μ„œλ²„ μ•‘μ…˜)
+export async function validateSAMLResponse(
+ samlResponse: string
+): Promise<SAMLProfile> {
+ "use server";
+
+ console.log("πŸ” Starting SAML Response validation...");
+ console.log("πŸ“Š SAML Response info:", {
+ responseLength: samlResponse.length,
+ firstChars: samlResponse.substring(0, 50) + "...",
+ isBase64: /^[A-Za-z0-9+/]*={0,2}$/.test(samlResponse),
+ timestamp: new Date().toISOString(),
+ });
+
+ // Mock IdP λͺ¨λ“œ 체크
+ if (process.env.SAML_MOCKING_IDP === 'true') {
+ debugMock("Mock IdP mode - returning mock SAML profile");
+ return createMockSAMLProfile(samlResponse);
+ }
+
+ // μ‹€μ œ SAML 검증 μˆ˜ν–‰ (κΈ°λ³Έκ°’)
+ console.log(
+ "πŸ” Using Real SAML validation (SAML_MOCKING_IDP=false or not set)"
+ );
+
+ try {
+ console.log("βš™οΈ Creating SAML instance for validation...");
+ const saml = new SAML(createSAMLConfig());
+ console.log("βœ… SAML instance created, starting validation...");
+
+ const startTime = Date.now();
+ const result = await saml.validatePostResponseAsync({
+ SAMLResponse: samlResponse,
+ });
+ const endTime = Date.now();
+
+ // node-saml λΌμ΄λΈŒλŸ¬λ¦¬λŠ” { profile, loggedOut } ν˜•νƒœλ‘œ λ°˜ν™˜
+ const profile = result.profile;
+ if (!profile) {
+ throw new Error("No profile returned from SAML validation");
+ }
+
+ // SAMLProfile ν˜•νƒœλ‘œ λ³€ν™˜ (νƒ€μž… μ•ˆμ „μ„± 확보)
+ const samlProfile: SAMLProfile = {
+ nameID: profile.nameID as string | undefined,
+ nameIDFormat: profile.nameIDFormat as string | undefined,
+ attributes: profile.attributes as Record<string, string | string[]> | undefined,
+ };
+
+ console.log("βœ… Real SAML Profile validated successfully:", {
+ nameID: samlProfile.nameID,
+ nameIDFormat: samlProfile.nameIDFormat,
+ attributeCount: Object.keys(samlProfile.attributes || {}).length,
+ attributes: Object.keys(samlProfile.attributes || {}),
+ processingTime: `${endTime - startTime}ms`,
+ timestamp: new Date().toISOString(),
+ });
+
+ return samlProfile;
+ } catch (error) {
+ console.error("❌ Real SAML validation error:", {
+ error: error instanceof Error ? error.message : "Unknown error",
+ stack: error instanceof Error ? error.stack : undefined,
+ samlResponseLength: samlResponse.length,
+ timestamp: new Date().toISOString(),
+ });
+ throw new Error(
+ `SAML validation failed: ${
+ error instanceof Error ? error.message : "Unknown error"
+ }`
+ );
+ }
+}
+
+// SAML Profile을 User 객체둜 λ³€ν™˜ (sync ν•¨μˆ˜)
+export function mapSAMLProfileToUser(profile: SAMLProfile): SAMLUser {
+ console.log("πŸ”„ Mapping SAML profile to user:", {
+ nameID: profile.nameID,
+ attributes: profile.attributes,
+ });
+
+ // SAML attributesλŠ” λ¬Έμžμ—΄ λ˜λŠ” λ°°μ—΄ ν˜•νƒœμΌ 수 있음
+ const extractAttributeValue = (key: string): string | undefined => {
+ const value = profile.attributes?.[key];
+ if (Array.isArray(value)) {
+ return value.length > 0 ? value[0] : undefined;
+ }
+ return typeof value === 'string' ? value : undefined;
+ };
+
+ // 기본적으둜 nameIDλ₯Ό μ‚¬μš©ν•˜κ±°λ‚˜ attributesμ—μ„œ μΆ”μΆœ
+ const id = profile.nameID || extractAttributeValue('id') || extractAttributeValue('sub');
+ const email = extractAttributeValue('email') || extractAttributeValue('emailAddress');
+ const name = extractAttributeValue('name') || extractAttributeValue('displayName') || extractAttributeValue('cn');
+
+ // ν•„μˆ˜ ν•„λ“œ 검증
+ if (!id) {
+ throw new Error('SAML profile missing required field: id (nameID)');
+ }
+ if (!email) {
+ throw new Error('SAML profile missing required field: email');
+ }
+ if (!name) {
+ throw new Error('SAML profile missing required field: name');
+ }
+
+ // UTF-8 λ¬Έμžμ—΄ μ •κ·œν™” 및 검증
+ const normalizedName = name.normalize("NFC").trim();
+
+ // ν•œκΈ€μ΄ κΉ¨μ§„ 경우 감지 및 둜그
+ const hasInvalidChars = /[\uFFFD\x00-\x1F\x7F-\x9F]/.test(normalizedName);
+ if (hasInvalidChars) {
+ console.warn("⚠️ Invalid UTF-8 characters detected in name:", {
+ originalName: name,
+ normalizedName,
+ charCodes: [...normalizedName].map((c) => c.charCodeAt(0)),
+ hexDump: [...normalizedName]
+ .map((c) => "\\x" + c.charCodeAt(0).toString(16).padStart(2, "0"))
+ .join(""),
+ });
+ }
+
+ // νšŒμ‚¬ μ •λ³΄λŠ” SSO 둜그인 μ‹œ μ—†μŒ (evcp 도메인)
+ const companyId = undefined;
+ const techCompanyId = undefined;
+ const domain = 'evcp';
+
+ const user: SAMLUser = {
+ id,
+ email,
+ name: normalizedName,
+ companyId,
+ techCompanyId,
+ domain,
+ };
+
+ console.log("πŸ‘€ Mapped user object:", JSON.stringify(user));
+
+ return user;
+}
+
+// Mock SAML ν”Œλ‘œμš° 생성 (ν…ŒμŠ€νŠΈμš©)
+function createMockSAMLFlow(relayState?: string): string {
+ debugMock("Creating mock SAML flow...", { relayState });
+
+ // Mock λͺ¨λ“œμ—μ„œλŠ” Mock IdP μ—”λ“œν¬μΈνŠΈλ‘œ λ¦¬λ‹€μ΄λ ‰μ…˜
+ const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000';
+ let mockIdpUrl = `${baseUrl}/api/auth/saml/mock-idp`;
+
+ // RelayStateκ°€ 있으면 URL νŒŒλΌλ―Έν„°λ‘œ 전달
+ if (relayState) {
+ mockIdpUrl += `?RelayState=${encodeURIComponent(relayState)}`;
+ }
+
+ debugMock("Mock SAML Flow - redirecting to Mock IdP:", mockIdpUrl);
+
+ return mockIdpUrl;
+}
+
+// Mock SAML Profile 생성 (ν…ŒμŠ€νŠΈμš©)
+function createMockSAMLProfile(samlResponse: string): SAMLProfile {
+ console.log("🎭 Creating mock SAML profile from response...");
+
+ try {
+ // SAML Responseκ°€ μš°λ¦¬κ°€ μƒμ„±ν•œ Mock인지 확인
+ const decodedXML = Buffer.from(samlResponse, 'base64').toString('utf-8');
+ const isMockResponse = decodedXML.includes('MockIdP');
+
+ if (!isMockResponse) {
+ console.warn("⚠️ Mock mode enabled but received non-mock SAML Response");
+ }
+
+ console.log("🎭 Mock SAML XML preview:", decodedXML.substring(0, 200) + "...");
+ } catch (error) {
+ console.warn("⚠️ Could not decode SAML Response for mock analysis:", (error as Error).message);
+ }
+
+ // Mock SAML Profile λ°˜ν™˜ (μ‹€μ œ SAML Response와 μΌμΉ˜ν•˜λ„λ‘ λ¬Έμžμ—΄ ν˜•νƒœ)
+ const mockProfile: SAMLProfile = {
+ nameID: "testuser@samsung.com",
+ nameIDFormat: "urn:oasis:names:tc:SAML:2.0:nameid-format:emailAddress",
+ attributes: {
+ email: "testuser@samsung.com",
+ name: "ν…ŒμŠ€νŠΈ μ‚¬μš©μž",
+ displayName: "Test User Samsung",
+ // μΆ”κ°€ ν…ŒμŠ€νŠΈ 속성듀
+ department: "κ°œλ°œνŒ€",
+ employeeId: "TEST001",
+ mobile: "010-1234-5678"
+ }
+ };
+
+ console.log("🎭 Mock SAML Profile created:", {
+ nameID: mockProfile.nameID,
+ nameIDFormat: mockProfile.nameIDFormat,
+ attributeCount: Object.keys(mockProfile.attributes || {}).length,
+ attributes: Object.keys(mockProfile.attributes || {}),
+ timestamp: new Date().toISOString(),
+ });
+
+ return mockProfile;
+}
+
+// SAML λ‘œκ·Έμ•„μ›ƒ URL 생성 (μ„œλ²„ μ•‘μ…˜)
+// λ‘œκ·Έμ•„μ›ƒ 지원 μ•ˆν•¨. 일단 ꡬ쑰만 μœ μ‚¬ν•˜κ²Œ μž‘μ„±ν•΄λ‘ .
+export async function createLogoutRequest(nameID: string): Promise<string> {
+ "use server";
+
+ const saml = new SAML(createSAMLConfig());
+ // Profile 객체 ν˜•νƒœλ‘œ 전달
+ const profile = { nameID };
+ return await saml.getLogoutUrlAsync(
+ profile,
+ "", // RelayState
+ {
+ nameIDFormat: "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress",
+ }
+ );
+}